4.4. Security
What is software security?
Software security is a specialized field of engineering focused on designing software to be resilient against malicious attacks and threats. It involves implementing a set of best practices and safeguards to protect data, preserve application integrity, and ensure that systems function as intended without unauthorized access or manipulation. In essence, it's about building software that can defend itself.
Why is software security crucial for any project?
Software security is non-negotiable for several fundamental reasons:
- Data Protection: It erects a barrier against unauthorized access to sensitive data, including user information, financial records, and proprietary intellectual property.
- Trust and Reputation: Secure software builds and maintains user trust. A single security breach can irreparably damage a company's reputation.
- Regulatory Compliance: Many industries are governed by strict data protection regulations (like GDPR or HIPAA). Adhering to security standards is often a legal necessity.
- Financial Stability: Breaches lead to direct financial losses from theft, regulatory fines, and the cost of remediation. They also cause indirect losses by eroding customer confidence.
- Service Availability: Robust security ensures that your applications remain operational, preventing costly downtime and disruptions to business continuity.
What are the primary security risks in Python and MLOps?
While Python and MLOps environments have unique challenges, the core security risks can be categorized as follows:
- Vulnerable Dependencies: Your project is only as secure as its weakest dependency. Using third-party libraries with known vulnerabilities is a primary attack vector.
- Improper Input Validation: Failing to validate and sanitize inputs from users or other systems can expose your application to injection attacks (e.g., SQL injection, command injection) or other exploits.
- Insecure Secrets Management: Hardcoding or improperly storing secrets like API keys, database credentials, and encryption keys makes them easy targets for theft.
- Model-Specific Threats: MLOps introduces unique vulnerabilities, including model poisoning (corrupting training data to compromise the model), data leakage (sensitive data being inadvertently exposed through model predictions), and inference attacks (reverse-engineering the model or its training data).
While the isolated nature of many MLOps backend processes offers some protection from direct internet threats, any component exposed to external interaction—such as an online inference API—must be rigorously secured.
How can you enhance security in a Python environment?
Using automated tools to scan for security problems is a highly effective strategy. Rather than a single tool, the canonical stack layers a few focused scanners, each mapped to a mise run check:* subtask so the same commands run locally and in CI:
-
Static security linting: Ruff enforces the
flake8-bandit(S) rules, which detect common security issues in Python code (hardcoded passwords,assertin production, unsafesubprocesscalls, insecure deserialization). This replaces the standalonebandittool with a single, much faster linter. Enable it by adding"S"to your Ruff selection:[tool.ruff.lint] select = ["S"] # flake8-bandit security rules (plus your other rules) [tool.ruff.lint.per-file-ignores] # asserts are fine in tests, so silence S101 there "tests/**" = ["S101"]Run it as part of linting with
uv run ruff check(wired tomise run check:lint). -
Vulnerable dependencies:
pip-auditchecks your resolved dependencies against known vulnerability databases.# Install pip-audit into your "dev" dependency group uv add --group dev pip-audit # Audit the project's dependencies (mise run check:vuln) uv run pip-audit --skip-editable --cache-dir .cache/pip-auditBoth flags matter.
--skip-editableexcludes your own project, which is installed in editable mode byuv syncand has no advisory database entry to look up.--cache-dir .cache/pip-auditmoves the advisory cache from the user's home directory into the repository, so a CI runner caches it with the rest of the checkout and every developer sees the same behavior; add.cache/to your.gitignore. -
Leaked secrets:
gitleaksscans your working tree and git history for accidentally committed credentials (mise run check:leaks). -
Misconfigurations, secrets, licenses, and vulnerabilities in the checkout:
trivyscans the repository against a policy you commit alongside it (mise run check:scan).
Together these give you defense in depth: Ruff S rules catch insecure code patterns, pip-audit catches vulnerable dependencies, gitleaks catches exposed secrets, and trivy catches risky configuration.
How should you invoke Trivy?
This is a place where an obvious-looking command quietly does far less than you think. The command the reference repositories run is:
# mise run check:scan
trivy --config trivy.yaml fs .
Two details in that line each fix a real bug.
Why --config trivy.yaml is explicit
Trivy discovers its configuration through a precedence chain, and an environment variable sits above the file in your repository. If a developer (or a base image, or a shell profile) exports TRIVY_CONFIG pointing at another policy, that policy silently wins over the trivy.yaml you committed. The scan still succeeds, still prints a clean result, and never applies a single one of your rules.
Passing --config trivy.yaml on the command line puts your repository's policy at the top of the chain. The scan now enforces what the repository says it enforces, on every machine, regardless of the surrounding environment.
Why fs and not config
trivy config . reads naturally as "scan my configuration", and that is exactly the trap. The config subcommand enables only the misconfiguration scanner. It does not honor the scan.scanners list in your configuration file — it has already decided what to run.
So a repository whose trivy.yaml declares four scanners, invoked as trivy config ., actually runs one of them. Three quarters of the policy never executed, and nothing in the output said so.
trivy fs . scans the filesystem with the scanners the configuration file declares. Same target, same policy file, four scanners instead of one.
What the policy file contains
# trivy.yaml
exit-code: 1
severity:
- HIGH
- CRITICAL
scan:
scanners:
- license
- misconfig
- secret
- vuln
# Caches and virtualenvs are not source. `.cache` in particular is written by
# `check:vuln` (pip-audit) while this scan runs in parallel, and trivy aborts when a
# temporary file disappears mid-walk.
skip-dirs:
- .cache
- .venv
- .git
vulnerability:
ignore-unfixed: true
Three choices are worth explaining:
severity: [HIGH, CRITICAL]keeps the gate actionable. A scanner that reports every LOW finding trains people to ignore it.ignore-unfixed: truehides vulnerabilities with no released fix. You cannot act on them today, and a permanently red build is a build nobody reads. Revisit this if you ship to a regulated environment that requires tracking them.skip-dirsis not cosmetic.mise run checkruns its subtasks in parallel, socheck:vuln(pip-audit) is writing into.cache/pip-auditat the same momentcheck:scanwalks the tree. Trivy aborts with a fatal error when a file it has listed disappears mid-walk, which makes the whole gate fail intermittently for a reason that has nothing to do with your code. Excluding caches, the virtualenv, and.gitremoves the race and cuts the scan time, because none of those directories are your source anyway.
How do you lint your CI/CD workflows and container image?
Two more scanners belong in mise run check, because workflow files and Dockerfiles are code with production consequences:
# mise.toml
[tasks."check:actions"]
description = "Lint and audit GitHub Actions workflows (actionlint + zizmor)"
run = ["actionlint", "zizmor --offline .github/workflows/"]
[tasks."check:dockerfile"]
description = "Lint the container image definition (hadolint)"
run = "hadolint Dockerfile"
actionlint catches syntax and expression errors in workflow files; zizmor audits them for security weaknesses such as script injection through untrusted github.event values or over-broad permissions. 5.3. CI/CD Workflows covers what these two find and how the workflows are written to satisfy them.
The reference repositories pin actions to full commit SHAs and keep version comments for review. Dependabot can update those SHAs through pull requests; the default zizmor policy verifies the pinning without a relaxation file.
hadolint lints the Dockerfile. It is the reason the reference image declares a numeric user instead of a name: rule DL3066 flags a non-numeric user id, because a host cannot resolve a username that only exists inside the image's /etc/passwd.
# Fixed numeric uid/gid: stable file ownership across rebuilds and bind mounts, and
# resolvable by a host that does not share this image's /etc/passwd.
RUN groupadd -r -g 10001 app && useradd -r -u 10001 -g app -m app
USER 10001:10001
COPY --from=build --chown=10001:10001 /app/.venv /app/.venv
Where should secret scanning run?
Once, in one place, is not enough. A committed secret has three distinct lifetimes, and each needs a different scan:
- Before it exists in history — the
pre-commithook runsmise run check:leaks --staged, which scans only the staged diff. This is the scan that actually saves you: a secret caught here never enters a commit object, so there is nothing to rotate and no history to rewrite. -
In the recent history — the
check:leakstask itself is deliberately bounded so it stays fast enough to run on every commit and every CI job:# mise.toml [tasks."check:leaks"] description = "Audit codebase for leaked secrets (gitleaks)" run = 'gitleaks git --log-opts="--max-count=100" --verbose' -
Anywhere in the full history — a secret that was committed and later deleted is invisible to a bounded scan forever after. That case needs a scheduled, full-depth job:
# .github/workflows/security.yml on: schedule: - cron: "17 3 * * 1" workflow_dispatch: jobs: scan: runs-on: ubuntu-latest steps: - name: Checkout complete history uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false # scanners only read the checkout - name: Install mise system uses: jdx/mise-action@08ae2a091c038e1e05ec6f5115dc5c69d5a2a6b8 # v5.1.2 - name: Scan complete Git history run: gitleaks git --redact=100 --verbose - name: Scan full checkout run: trivy fs .fetch-depth: 0is the whole point: the default shallow checkout cannot see the commit where the secret was introduced.--redact=100keeps the secret itself out of the public workflow logs.
How can GitHub help manage security risks?
GitHub provides powerful, integrated tools to automate security monitoring. Dependabot is a key feature that automatically scans your project's dependencies for known vulnerabilities and opens pull requests to update them to secure versions.
To enable Dependabot, create a configuration file at .github/dependabot.yml. This file tells Dependabot which package ecosystems to monitor, how often to check, how to group updates, and how to word its commits.
# .github/dependabot.yml
# https://docs.github.com/en/code-security/dependabot/working-with-dependabot/dependabot-options-reference
# Minor and patch updates arrive grouped, one pull request per ecosystem; majors stay
# separate so a breaking bump is always reviewed on its own.
version: 2
updates:
- package-ecosystem: "uv"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
commit-message:
prefix: "chore(deps)"
groups:
python:
patterns: ["*"]
update-types: ["minor", "patch"]
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
commit-message:
prefix: "chore(deps)"
groups:
actions:
patterns: ["*"]
update-types: ["minor", "patch"]
- package-ecosystem: "docker"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
commit-message:
prefix: "chore(deps)"
groups:
docker:
patterns: ["*"]
update-types: ["minor", "patch"]
Four choices here are worth the extra lines:
- Three ecosystems, not one.
uvcovers your Python dependencies,github-actionscovers the actions your workflows call, anddockercovers base images in yourDockerfile. A project that monitors only Python is still running last year'sactions/checkoutand last year's base image. day: "monday"turns dependency review into a predictable Monday-morning task instead of an unscheduled interruption.groupscollapses every minor and patch bump in one ecosystem into a single pull request. Becauseupdate-typeslists onlyminorandpatch, major bumps stay on their own pull request, where a breaking change gets reviewed in isolation.-
commit-message.prefix: "chore(deps)"aligns Dependabot with your changelog configuration, and this one bit us. Thecliff.tomlused to generate the changelog (see 6.3. Releases) already excluded dependency noise:# cliff.toml [git] commit_parsers = [{ message = "^chore\\(deps\\)", skip = true }]But Dependabot's default prefix is
build, so its commits arrived asbuild(deps): ...(orbuild(deps-dev): ...for development dependencies). The filter never matched anything, and ten dependency bumps ended up in a published changelog written for humans. Nothing was broken enough to notice; the two configuration files simply disagreed about a string. Setting the prefix explicitly makes the filter do the job it was written for.
When may you override an upstream version constraint?
Prefer a compatible upstream release that permits the patched dependency. Regenerate the lockfile, test the affected integration, and rerun the vulnerability scan.
When no such release exists, a temporary uv dependency override may be justified. It bypasses an upstream compatibility claim: verify why the bound exists, document the advisory and removal condition, and exercise the affected behavior. Passing a project test suite supplies evidence about the tested paths; it does not prove every dependency path is safe.
Suppressing an advisory changes what the scanner reports while leaving the installed code unchanged. If a safe compatible installation cannot be established, record the unresolved risk and choose a replacement or another supported release rather than describing the gate as clean.